'use strict';
const {
  ArrayFrom,
  ArrayPrototypeMap,
  ArrayPrototypePush,
  JSONParse,
  MathFloor,
  NumberParseInt,
  RegExpPrototypeExec,
  RegExpPrototypeSymbolSplit,
  SafeMap,
  SafeSet,
  StringPrototypeIncludes,
  StringPrototypeLocaleCompare,
  StringPrototypeStartsWith,
  MathMax,
} = primordials;
const {
  copyFileSync,
  mkdirSync,
  mkdtempSync,
  opendirSync,
  readFileSync,
} = require('fs');
const { setupCoverageHooks } = require('internal/util');
const { tmpdir } = require('os');
const { join, resolve } = require('path');
const { fileURLToPath } = require('internal/url');
const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
const kLineEndingRegex = /\r?\n$/u;
const kLineSplitRegex = /(?<=\r?\n)/u;
const kStatusRegex = /\/\* node:coverage (?<status>enable|disable) \*\//;

class CoverageLine {
  #covered;

  constructor(line, src, startOffset) {
    const newlineLength =
      RegExpPrototypeExec(kLineEndingRegex, src)?.[0].length ?? 0;

    this.line = line;
    this.src = src;
    this.startOffset = startOffset;
    this.endOffset = startOffset + src.length - newlineLength;
    this.ignore = false;
    this.count = 0;
    this.#covered = true;
  }

  get covered() {
    return this.#covered;
  }

  set covered(isCovered) {
    // V8 can generate multiple ranges that span the same line.
    if (!this.#covered) {
      return;
    }

    this.#covered = isCovered;
  }
}

class TestCoverage {
  constructor(coverageDirectory, originalCoverageDirectory, workingDirectory) {
    this.coverageDirectory = coverageDirectory;
    this.originalCoverageDirectory = originalCoverageDirectory;
    this.workingDirectory = workingDirectory;
  }

  summary() {
    internalBinding('profiler').takeCoverage();
    const coverage = getCoverageFromDirectory(this.coverageDirectory);
    const coverageSummary = {
      __proto__: null,
      workingDirectory: this.workingDirectory,
      files: [],
      totals: {
        __proto__: null,
        totalLineCount: 0,
        totalBranchCount: 0,
        totalFunctionCount: 0,
        coveredLineCount: 0,
        coveredBranchCount: 0,
        coveredFunctionCount: 0,
        coveredLinePercent: 0,
        coveredBranchPercent: 0,
        coveredFunctionPercent: 0,
      },
    };

    if (!coverage) {
      return coverageSummary;
    }

    for (let i = 0; i < coverage.length; ++i) {
      const { functions, url } = coverage[i];

      // Split the file source into lines. Make sure the lines maintain their
      // original line endings because those characters are necessary for
      // determining offsets in the file.
      const filePath = fileURLToPath(url);
      let source;

      try {
        source = readFileSync(filePath, 'utf8');
      } catch {
        // The file can no longer be read. It may have been deleted among
        // other possibilities. Leave it out of the coverage report.
        continue;
      }

      const linesWithBreaks =
        RegExpPrototypeSymbolSplit(kLineSplitRegex, source);
      let ignoreCount = 0;
      let enabled = true;
      let offset = 0;
      let totalBranches = 0;
      let totalFunctions = 0;
      let branchesCovered = 0;
      let functionsCovered = 0;
      const functionReports = [];
      const branchReports = [];

      const lines = ArrayPrototypeMap(linesWithBreaks, (line, i) => {
        const startOffset = offset;
        const coverageLine = new CoverageLine(i + 1, line, startOffset);

        offset += line.length;

        // Determine if this line is being ignored.
        if (ignoreCount > 0) {
          ignoreCount--;
          coverageLine.ignore = true;
        } else if (!enabled) {
          coverageLine.ignore = true;
        }

        if (!coverageLine.ignore) {
          // If this line is not already being ignored, check for ignore
          // comments.
          const match = RegExpPrototypeExec(kIgnoreRegex, line);

          if (match !== null) {
            ignoreCount = NumberParseInt(match.groups?.count ?? 1, 10);
          }
        }

        // Check for comments to enable/disable coverage no matter what. These
        // take precedence over ignore comments.
        const match = RegExpPrototypeExec(kStatusRegex, line);
        const status = match?.groups?.status;

        if (status) {
          ignoreCount = 0;
          enabled = status === 'enable';
        }

        return coverageLine;
      });

      for (let j = 0; j < functions.length; ++j) {
        const { isBlockCoverage, ranges } = functions[j];

        let maxCountPerFunction = 0;
        for (let k = 0; k < ranges.length; ++k) {
          const range = ranges[k];
          maxCountPerFunction = MathMax(maxCountPerFunction, range.count);

          mapRangeToLines(range, lines);

          if (isBlockCoverage) {
            ArrayPrototypePush(branchReports, {
              __proto__: null,
              line: range.lines[0].line,
              count: range.count,
            });

            if (range.count !== 0 ||
                range.ignoredLines === range.lines.length) {
              branchesCovered++;
            }

            totalBranches++;
          }
        }

        if (j > 0 && ranges.length > 0) {
          const range = ranges[0];

          ArrayPrototypePush(functionReports, {
            __proto__: null,
            name: functions[j].functionName,
            count: maxCountPerFunction,
            line: range.lines[0].line,
          });

          if (range.count !== 0 || range.ignoredLines === range.lines.length) {
            functionsCovered++;
          }

          totalFunctions++;
        }
      }

      let coveredCnt = 0;
      const lineReports = [];

      for (let j = 0; j < lines.length; ++j) {
        const line = lines[j];
        if (!line.ignore) {
          ArrayPrototypePush(lineReports, {
            __proto__: null,
            line: line.line,
            count: line.count,
          });
        }
        if (line.covered || line.ignore) {
          coveredCnt++;
        }
      }

      ArrayPrototypePush(coverageSummary.files, {
        __proto__: null,
        path: filePath,
        totalLineCount: lines.length,
        totalBranchCount: totalBranches,
        totalFunctionCount: totalFunctions,
        coveredLineCount: coveredCnt,
        coveredBranchCount: branchesCovered,
        coveredFunctionCount: functionsCovered,
        coveredLinePercent: toPercentage(coveredCnt, lines.length),
        coveredBranchPercent: toPercentage(branchesCovered, totalBranches),
        coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions),
        functions: functionReports,
        branches: branchReports,
        lines: lineReports,
      });

      coverageSummary.totals.totalLineCount += lines.length;
      coverageSummary.totals.totalBranchCount += totalBranches;
      coverageSummary.totals.totalFunctionCount += totalFunctions;
      coverageSummary.totals.coveredLineCount += coveredCnt;
      coverageSummary.totals.coveredBranchCount += branchesCovered;
      coverageSummary.totals.coveredFunctionCount += functionsCovered;
    }

    coverageSummary.totals.coveredLinePercent = toPercentage(
      coverageSummary.totals.coveredLineCount,
      coverageSummary.totals.totalLineCount,
    );
    coverageSummary.totals.coveredBranchPercent = toPercentage(
      coverageSummary.totals.coveredBranchCount,
      coverageSummary.totals.totalBranchCount,
    );
    coverageSummary.totals.coveredFunctionPercent = toPercentage(
      coverageSummary.totals.coveredFunctionCount,
      coverageSummary.totals.totalFunctionCount,
    );
    coverageSummary.files.sort(sortCoverageFiles);

    return coverageSummary;
  }

  cleanup() {
    // Restore the original value of process.env.NODE_V8_COVERAGE. Then, copy
    // all of the created coverage files to the original coverage directory.
    if (this.originalCoverageDirectory === undefined) {
      delete process.env.NODE_V8_COVERAGE;
      return;
    }

    process.env.NODE_V8_COVERAGE = this.originalCoverageDirectory;
    let dir;

    try {
      mkdirSync(this.originalCoverageDirectory, { __proto__: null, recursive: true });
      dir = opendirSync(this.coverageDirectory);

      for (let entry; (entry = dir.readSync()) !== null;) {
        const src = join(this.coverageDirectory, entry.name);
        const dst = join(this.originalCoverageDirectory, entry.name);
        copyFileSync(src, dst);
      }
    } finally {
      if (dir) {
        dir.closeSync();
      }
    }
  }
}

function toPercentage(covered, total) {
  return total === 0 ? 100 : (covered / total) * 100;
}

function sortCoverageFiles(a, b) {
  return StringPrototypeLocaleCompare(a.path, b.path);
}

function setupCoverage() {
  let originalCoverageDirectory = process.env.NODE_V8_COVERAGE;
  const cwd = process.cwd();

  if (originalCoverageDirectory) {
    // NODE_V8_COVERAGE was already specified. Convert it to an absolute path
    // and store it for later. The test runner will use a temporary directory
    // so that no preexisting coverage files interfere with the results of the
    // coverage report. Then, once the coverage is computed, move the coverage
    // files back to the original NODE_V8_COVERAGE directory.
    originalCoverageDirectory = resolve(cwd, originalCoverageDirectory);
  }

  const coverageDirectory = mkdtempSync(join(tmpdir(), 'node-coverage-'));
  const enabled = setupCoverageHooks(coverageDirectory);

  if (!enabled) {
    return null;
  }

  // Ensure that NODE_V8_COVERAGE is set so that coverage can propagate to
  // child processes.
  process.env.NODE_V8_COVERAGE = coverageDirectory;

  return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd);
}

function mapRangeToLines(range, lines) {
  const { startOffset, endOffset, count } = range;
  const mappedLines = [];
  let ignoredLines = 0;
  let start = 0;
  let end = lines.length;
  let mid;

  while (start <= end) {
    mid = MathFloor((start + end) / 2);
    let line = lines[mid];

    if (startOffset >= line?.startOffset && startOffset <= line?.endOffset) {
      while (endOffset > line?.startOffset) {
        // If the range is not covered, and the range covers the entire line,
        // then mark that line as not covered.
        if (count === 0 && startOffset <= line.startOffset &&
            endOffset >= line.endOffset) {
          line.covered = false;
          line.count = 0;
        }
        if (count > 0 && startOffset <= line.startOffset &&
            endOffset >= line.endOffset) {
          line.count = count;
        }

        ArrayPrototypePush(mappedLines, line);

        if (line.ignore) {
          ignoredLines++;
        }

        mid++;
        line = lines[mid];
      }

      break;
    } else if (startOffset >= line?.endOffset) {
      start = mid + 1;
    } else {
      end = mid - 1;
    }
  }

  // Add some useful data to the range. The test runner has read these ranges
  // from a file, so we own the data structures and can do what we want.
  range.lines = mappedLines;
  range.ignoredLines = ignoredLines;
}

function getCoverageFromDirectory(coverageDirectory) {
  const result = new SafeMap();
  let dir;

  try {
    dir = opendirSync(coverageDirectory);

    for (let entry; (entry = dir.readSync()) !== null;) {
      if (RegExpPrototypeExec(kCoverageFileRegex, entry.name) === null) {
        continue;
      }

      const coverageFile = join(coverageDirectory, entry.name);
      const coverage = JSONParse(readFileSync(coverageFile, 'utf8'));

      mergeCoverage(result, coverage.result);
    }

    return ArrayFrom(result.values());
  } finally {
    if (dir) {
      dir.closeSync();
    }
  }
}

function mergeCoverage(merged, coverage) {
  for (let i = 0; i < coverage.length; ++i) {
    const newScript = coverage[i];
    const { url } = newScript;

    // The first part of this check filters out the node_modules/ directory
    // from the results. This filter is applied first because most real world
    // applications will be dominated by third party dependencies. The second
    // part of the check filters out core modules, which start with 'node:' in
    // coverage reports, as well as any invalid coverages which have been
    // observed on Windows.
    if (StringPrototypeIncludes(url, '/node_modules/') ||
        !StringPrototypeStartsWith(url, 'file:')) {
      continue;
    }

    const oldScript = merged.get(url);

    if (oldScript === undefined) {
      merged.set(url, newScript);
    } else {
      mergeCoverageScripts(oldScript, newScript);
    }
  }
}

function mergeCoverageScripts(oldScript, newScript) {
  // Merge the functions from the new coverage into the functions from the
  // existing (merged) coverage.
  for (let i = 0; i < newScript.functions.length; ++i) {
    const newFn = newScript.functions[i];
    let found = false;

    for (let j = 0; j < oldScript.functions.length; ++j) {
      const oldFn = oldScript.functions[j];

      if (newFn.functionName === oldFn.functionName &&
          newFn.ranges?.[0].startOffset === oldFn.ranges?.[0].startOffset &&
          newFn.ranges?.[0].endOffset === oldFn.ranges?.[0].endOffset) {
        // These are the same functions.
        found = true;

        // If newFn is block level coverage, then it will:
        // - Replace oldFn if oldFn is not block level coverage.
        // - Merge with oldFn if it is also block level coverage.
        // If newFn is not block level coverage, then it has no new data.
        if (newFn.isBlockCoverage) {
          if (oldFn.isBlockCoverage) {
            // Merge the oldFn ranges with the newFn ranges.
            mergeCoverageRanges(oldFn, newFn);
          } else {
            // Replace oldFn with newFn.
            oldFn.isBlockCoverage = true;
            oldFn.ranges = newFn.ranges;
          }
        }

        break;
      }
    }

    if (!found) {
      // This is a new function to track. This is possible because V8 can
      // generate a different list of functions depending on which code paths
      // are executed. For example, if a code path dynamically creates a
      // function, but that code path is not executed then the function does
      // not show up in the coverage report. Unfortunately, this also means
      // that the function counts in the coverage summary can never be
      // guaranteed to be 100% accurate.
      ArrayPrototypePush(oldScript.functions, newFn);
    }
  }
}

function mergeCoverageRanges(oldFn, newFn) {
  const mergedRanges = new SafeSet();

  // Keep all of the existing covered ranges.
  for (let i = 0; i < oldFn.ranges.length; ++i) {
    const oldRange = oldFn.ranges[i];

    if (oldRange.count > 0) {
      mergedRanges.add(oldRange);
    }
  }

  // Merge in the new ranges where appropriate.
  for (let i = 0; i < newFn.ranges.length; ++i) {
    const newRange = newFn.ranges[i];
    let exactMatch = false;

    for (let j = 0; j < oldFn.ranges.length; ++j) {
      const oldRange = oldFn.ranges[j];

      if (doesRangeEqualOtherRange(newRange, oldRange)) {
        // These are the same ranges, so keep the existing one.
        oldRange.count += newRange.count;
        mergedRanges.add(oldRange);
        exactMatch = true;
        break;
      }

      // Look at ranges representing missing coverage and add ranges that
      // represent the intersection.
      if (oldRange.count === 0 && newRange.count === 0) {
        if (doesRangeContainOtherRange(oldRange, newRange)) {
          // The new range is completely within the old range. Discard the
          // larger (old) range, and keep the smaller (new) range.
          mergedRanges.add(newRange);
        } else if (doesRangeContainOtherRange(newRange, oldRange)) {
          // The old range is completely within the new range. Discard the
          // larger (new) range, and keep the smaller (old) range.
          mergedRanges.add(oldRange);
        }
      }
    }

    // Add new ranges that do not represent missing coverage.
    if (newRange.count > 0 && !exactMatch) {
      mergedRanges.add(newRange);
    }
  }

  oldFn.ranges = ArrayFrom(mergedRanges);
}

function doesRangeEqualOtherRange(range, otherRange) {
  return range.startOffset === otherRange.startOffset &&
         range.endOffset === otherRange.endOffset;
}

function doesRangeContainOtherRange(range, otherRange) {
  return range.startOffset <= otherRange.startOffset &&
         range.endOffset >= otherRange.endOffset;
}

module.exports = { setupCoverage, TestCoverage };
